import { _exists, _missing } from '../agStack/utils/generic';
import { _isExpressionString } from '../agStack/utils/string';
import { _getValueUsingField } from '../agStack/utils/value';
import type { ColumnModel } from '../columns/columnModel';
import type { DataTypeService } from '../columns/dataTypeService';
import type { NamedBean } from '../context/bean';
import { BeanStub } from '../context/beanStub';
import type { BeanCollection } from '../context/context';
import type { AgColumn } from '../entities/agColumn';
import type {
    KeyCreatorParams,
    ValueFormatterParams,
    ValueGetterParams,
    ValueParserParams,
    ValueSetterParams,
} from '../entities/colDef';
import type { RowNode } from '../entities/rowNode';
import type { CellValueChangedEvent } from '../events';
import { _addGridCommonParams, _isServerSideRowModel } from '../gridOptionsUtils';
import type { IFormulaDataService } from '../interfaces/formulas';
import type { IEditService } from '../interfaces/iEditService';
import type { IRowNode } from '../interfaces/iRowNode';
import { _warn } from '../validation/logging';
import type { ExpressionService } from './expressionService';
import type { ValueCache } from './valueCache';

export class ValueService extends BeanStub implements NamedBean {
    beanName = 'valueSvc' as const;

    private expressionSvc?: ExpressionService;
    private colModel: ColumnModel;
    private valueCache?: ValueCache;
    private dataTypeSvc?: DataTypeService;
    private editSvc?: IEditService;
    private hasEditSvc: boolean = false;
    private formulaDataSvc?: IFormulaDataService;

    public wireBeans(beans: BeanCollection): void {
        this.expressionSvc = beans.expressionSvc;
        this.colModel = beans.colModel;
        this.valueCache = beans.valueCache;
        this.dataTypeSvc = beans.dataTypeSvc;
        this.editSvc = beans.editSvc;
        this.hasEditSvc = !!beans.editSvc;
        this.formulaDataSvc = beans.formulaDataSvc;
    }

    private cellExpressions: boolean;

    // Store locally for performance reasons and keep updated via property listener
    private isTreeData: boolean;

    private initialised = false;

    private isSsrm = false;

    private executeValueGetter: (
        // eslint-disable-next-line @typescript-eslint/ban-types
        valueGetter: string | Function,
        data: any,
        column: AgColumn,
        rowNode: IRowNode
    ) => any;

    public postConstruct(): void {
        if (!this.initialised) {
            this.init();
        }
    }

    private init(): void {
        const { gos, valueCache } = this;
        this.executeValueGetter = valueCache
            ? this.executeValueGetterWithValueCache.bind(this)
            : this.executeValueGetterWithoutValueCache.bind(this);
        this.isSsrm = _isServerSideRowModel(gos);
        this.cellExpressions = gos.get('enableCellExpressions');
        this.isTreeData = gos.get('treeData');
        this.initialised = true;

        // We listen to our own event and use it to call the columnSpecific callback,
        // this way the handler calls are correctly interleaved with other global events
        const listener = (event: CellValueChangedEvent) => this.callColumnCellValueChangedHandler(event);
        this.eventSvc.addListener('cellValueChanged', listener, true);
        this.addDestroyFunc(() => this.eventSvc.removeListener('cellValueChanged', listener, true));

        this.addManagedPropertyListener('treeData', (propChange) => (this.isTreeData = propChange.currentValue));
    }

    /**
     * Use this function to get a displayable cell value.
     * The values from this function are not used for sorting, filtering, or aggregation purposes.
     * Handles: groupHideOpenParents, showOpenedGroup and groupSuppressBlankHeader behaviours
     */
    public getValueForDisplay(params: {
        column?: AgColumn;
        node: IRowNode;
        includeValueFormatted?: boolean;
        useRawFormula?: boolean;
        exporting?: boolean;
        source?: 'ui' | 'api';
    }): {
        value: any;
        valueFormatted: string | null;
    } {
        const { column, node, includeValueFormatted, useRawFormula, exporting, source = 'ui' } = params;
        const { showRowGroupColValueSvc } = this.beans;
        const isFullWidthGroup = !column && node.group;
        const isGroupCol = column?.colDef.showRowGroup;

        // Tree data auto col acts as a traditional column, with the exception of footers, so only process footers with
        // showRowGroupColValueSvc
        const processTreeDataAsGroup = !this.isTreeData || node.footer;

        // handle group cell value
        if (showRowGroupColValueSvc && processTreeDataAsGroup && (isFullWidthGroup || isGroupCol)) {
            const groupValue = showRowGroupColValueSvc.getGroupValue(node, column);
            if (groupValue == null) {
                return {
                    value: null,
                    valueFormatted: null,
                };
            }

            if (!includeValueFormatted) {
                return {
                    value: groupValue.value,
                    valueFormatted: null,
                };
            }

            const valueFormatted = showRowGroupColValueSvc.formatAndPrefixGroupColValue(groupValue, column, exporting);
            return {
                value: groupValue.value,
                valueFormatted,
            };
        }

        // full width row, not full width group - probably should be supported by getValue
        if (!column) {
            return {
                value: node.key,
                valueFormatted: null,
            };
        }

        // when in pivot mode, leafGroups cannot be expanded
        const isPivotLeaf = node.leafGroup && this.colModel.isPivotMode();
        const isOpenedGroup = node.group && node.expanded && !node.footer && !isPivotLeaf;
        // checks if we show header data
        const groupShowsAggData = this.gos.get('groupSuppressBlankHeader') || !node.sibling;

        // if doing grouping and footers, we don't want to include the agg value
        // in the header when the group is open
        const ignoreAggData = isOpenedGroup && !groupShowsAggData;
        let value = this.getValue(column, node, ignoreAggData, source);
        let valueToFormat = value;

        const { formula } = this.beans;
        const format = includeValueFormatted && !(exporting && column.colDef.useValueFormatterForExport === false);
        if (column.isAllowFormula() && formula?.isFormula(value)) {
            if (useRawFormula) {
                value = formula.normaliseFormula(value, true);
                valueToFormat = formula.resolveValue(column, node as RowNode);
            } else {
                value = formula.resolveValue(column, node as RowNode);
                valueToFormat = value;
            }
        }

        return {
            value,
            valueFormatted: format ? this.formatValue(column, node, valueToFormat) : null,
        };
    }

    public getValue(
        column: AgColumn,
        rowNode?: IRowNode | null,
        ignoreAggData = false,
        source: 'ui' | 'api' | 'edit' | string = 'ui'
    ): any {
        // hack - the grid is getting refreshed before this bean gets initialised, race condition.
        // really should have a way so they get initialised in the right order???
        if (!this.initialised) {
            this.init();
        }

        if (!rowNode) {
            return;
        }

        // pull these out to make code below easier to read

        if (this.hasEditSvc && source === 'ui') {
            const editSvc = this.editSvc!;

            // if the row is editing, we want to return the new value, if available
            if (editSvc.isEditing()) {
                const newValue = editSvc.getCellDataValue({ rowNode, column }, true);
                if (newValue !== undefined) {
                    return newValue;
                }
            }
        }

        const colDef = column.getColDef();
        // when using multiple columns, the group column should have no value higher than its level
        const rowGroupColId = colDef.showRowGroup;
        if (typeof rowGroupColId === 'string') {
            // if multiple columns, don't show values in cells grouped at a higher level
            const colRowGroupIndex = this.beans.rowGroupColsSvc?.getColumnIndex(rowGroupColId) ?? -1;
            if (colRowGroupIndex > rowNode.level) {
                return null;
            }
        }

        let result = this.resolveValue(column, rowNode, ignoreAggData);

        // the result could be an expression itself, if we are allowing cell values to be expressions
        if (this.cellExpressions && _isExpressionString(result)) {
            const cellValueGetter = result.substring(1);
            result = this.executeValueGetter(cellValueGetter, rowNode.data, column, rowNode);
        }

        return result;
    }

    private getFormulaFromDataSource(column: AgColumn, rowNode: IRowNode): string | undefined {
        const dataSource = this.formulaDataSvc;
        if (!dataSource?.hasDataSource() || !column.isAllowFormula()) {
            return undefined;
        }

        const formula = dataSource.getFormula({ column, rowNode });
        return _isExpressionString(formula) ? formula : undefined;
    }

    private resolveValue(column: AgColumn, rowNode: IRowNode, ignoreAggData: boolean): any {
        const colDef = column.getColDef();
        const colId = column.getColId();

        const isTreeData = this.isTreeData;

        const dataSourceFormula = this.getFormulaFromDataSource(column, rowNode);
        if (dataSourceFormula !== undefined) {
            return dataSourceFormula;
        }

        // if there is a value getter, this gets precedence over a field
        const aggDataExists = !ignoreAggData && rowNode.aggData && rowNode.aggData[colId] !== undefined;
        if (isTreeData && aggDataExists) {
            return rowNode.aggData[colId];
        }

        const data = rowNode.data;
        const field = colDef.field;
        if (isTreeData && colDef.valueGetter) {
            return this.executeValueGetter(colDef.valueGetter, data, column, rowNode);
        }
        if (isTreeData && field && data) {
            return _getValueUsingField(data, field, column.isFieldContainsDots());
        }

        const groupData = rowNode.groupData;
        const groupDataExists = groupData && colId in groupData;
        if (groupDataExists) {
            return rowNode.groupData![colId];
        }
        if (aggDataExists) {
            return rowNode.aggData[colId];
        }

        // don't retrieve group values from field or valueGetter for multiple auto cols
        const rowGroupColId = colDef.showRowGroup;
        const allowUserValuesForCell = typeof rowGroupColId !== 'string' || !rowNode.group;

        // SSRM agg data comes from the data attribute, so ignore that instead
        const ignoreSsrmAggData = this.isSsrm && ignoreAggData && !!colDef.aggFunc;
        const ssrmFooterGroupCol =
            this.isSsrm &&
            rowNode.footer &&
            rowNode.field &&
            (rowGroupColId === true || rowGroupColId === rowNode.field);

        if (colDef.valueGetter && !ignoreSsrmAggData) {
            if (!allowUserValuesForCell) {
                return undefined;
            }
            return this.executeValueGetter(colDef.valueGetter, data, column, rowNode);
        }
        if (ssrmFooterGroupCol) {
            // this is for group footers in SSRM, as the SSRM row won't have groupData, need to extract
            // the group value from the data using the row field
            return _getValueUsingField(data, rowNode.field!, column.isFieldContainsDots());
        }
        if (field && data && !ignoreSsrmAggData) {
            if (!allowUserValuesForCell) {
                return undefined;
            }
            return _getValueUsingField(data, field, column.isFieldContainsDots());
        }

        return undefined;
    }

    public parseValue(column: AgColumn, rowNode: IRowNode | null, newValue: any, oldValue: any): any {
        const colDef = column.getColDef();

        // we do not allow parsing of formulas
        if (colDef.allowFormula && this.beans.formula?.isFormula(newValue)) {
            return newValue;
        }

        const valueParser = colDef.valueParser;

        if (_exists(valueParser)) {
            const params: ValueParserParams = _addGridCommonParams(this.gos, {
                node: rowNode,
                data: rowNode?.data,
                oldValue,
                newValue,
                colDef,
                column,
            });
            if (typeof valueParser === 'function') {
                return valueParser(params);
            }
            return this.expressionSvc?.evaluate(valueParser, params);
        }
        return newValue;
    }

    public getDeleteValue(column: AgColumn, rowNode: IRowNode): any {
        if (_exists(column.getColDef().valueParser)) {
            return (
                this.parseValue(column, rowNode, '', this.getValueForDisplay({ column, node: rowNode }).value) ?? null
            );
        }
        return null;
    }

    public formatValue(
        column: AgColumn,
        node: IRowNode | null,
        value: any,
        suppliedFormatter?: (value: any) => string,
        useFormatterFromColumn = true
    ): string | null {
        const { expressionSvc } = this.beans;
        let result: string | null = null;
        let formatter: ((value: any) => string) | string | undefined;

        const colDef = column.getColDef();

        if (suppliedFormatter) {
            // use supplied formatter if provided, e.g. set filter items can have their own value formatters
            formatter = suppliedFormatter;
        } else if (useFormatterFromColumn) {
            formatter = colDef.valueFormatter;
        }

        if (formatter) {
            const data = node ? node.data : null;

            const params: ValueFormatterParams = _addGridCommonParams(this.gos, {
                value,
                node,
                data,
                colDef,
                column,
            });
            if (typeof formatter === 'function') {
                result = formatter(params);
            } else {
                result = expressionSvc ? expressionSvc.evaluate(formatter, params) : null;
            }
        } else if (colDef.refData) {
            return colDef.refData[value] || '';
        }

        // if we don't do this, then arrays get displayed as 1,2,3, but we want 1, 2, 3 (i.e. with spaces)
        if (result == null && Array.isArray(value)) {
            result = value.join(', ');
        }

        return result;
    }

    /**
     * Sets the value of a GridCell
     * @param rowNode The `RowNode` to be updated
     * @param colKey The `Column` to be updated
     * @param newValue The new value to be set
     * @param eventSource The event source
     * @returns `True` if the value has been updated, otherwise`False`.
     */
    public setValue(rowNode: IRowNode, colKey: string | AgColumn, newValue: any, eventSource?: string): boolean {
        const column = this.colModel.getColDefCol(colKey);

        if (!rowNode || !column) {
            return false;
        }
        this.ensureRowData(rowNode);

        const colDef = column.getColDef();
        if (!this.isSetValueSupported({ column, newValue, colDef })) {
            return false;
        }

        const params: ValueSetterParams = _addGridCommonParams(this.gos, {
            node: rowNode,
            data: rowNode.data,
            oldValue: this.getValue(column, rowNode, undefined, eventSource),
            newValue: newValue,
            colDef,
            column: column,
        });

        params.newValue = newValue;

        const externalFormulaResult = this.handleExternalFormulaChange({
            column,
            eventSource,
            newValue,
            setterParams: params,
            rowNode,
        });
        if (externalFormulaResult !== null) {
            return externalFormulaResult;
        }

        let valueWasDifferent = this.computeValueChange({
            column,
            newValue,
            params,
            rowData: rowNode.data,
            valueSetter: colDef.valueSetter,
            field: colDef.field,
        });

        // in case user forgot to return something (possible if they are not using TypeScript
        // and just forgot we default the return value to true, so we always refresh.
        if (valueWasDifferent === undefined) {
            valueWasDifferent = true;
        }

        // if no change to the value, then no need to do the updating, or notifying via events.
        // otherwise the user could be tabbing around the grid, and cellValueChange would get called
        // all the time.
        if (!valueWasDifferent) {
            return false;
        }

        return this.finishValueChange(rowNode, column, params, eventSource);
    }

    private finishValueChange(
        rowNode: IRowNode,
        column: AgColumn,
        params: ValueSetterParams,
        eventSource?: string
    ): boolean {
        // reset quick filter on this row
        rowNode.resetQuickFilterAggregateText();

        this.valueCache?.onDataChanged();

        const savedValue = this.getValue(column, rowNode);

        this.dispatchCellValueChangedEvent(rowNode, params, savedValue, eventSource);
        if ((rowNode as RowNode).pinnedSibling) {
            this.dispatchCellValueChangedEvent((rowNode as RowNode).pinnedSibling!, params, savedValue, eventSource);
        }

        return true;
    }

    private ensureRowData(rowNode: IRowNode): void {
        // enableGroupEdit allows editing group rows without data.
        if (_missing(rowNode.data)) {
            rowNode.data = {};
        }
    }

    private isSetValueSupported(params: {
        column: AgColumn;
        newValue: any;
        colDef: ReturnType<AgColumn['getColDef']>;
    }): boolean {
        const { column, newValue, colDef } = params;
        const { field, valueSetter } = colDef;

        const formulaSvc = this.beans.formula;
        const isFormulaValue = column.isAllowFormula() && formulaSvc?.isFormula(newValue);
        const hasExternalFormulaData = !!this.formulaDataSvc?.hasDataSource();

        if (_missing(field) && _missing(valueSetter) && !(hasExternalFormulaData && isFormulaValue)) {
            _warn(17);
            return false;
        }

        if (this.dataTypeSvc && !this.dataTypeSvc.checkType(column, newValue)) {
            _warn(135);
            return false;
        }

        return true;
    }

    private handleExternalFormulaChange(args: {
        column: AgColumn;
        rowNode: IRowNode;
        newValue: any;
        setterParams: ValueSetterParams;
        eventSource?: string;
    }): boolean | null {
        const { column, rowNode, newValue, eventSource, setterParams } = args;
        const formulaSvc = this.beans.formula;
        const formulaDataSvc = this.formulaDataSvc;
        if (!formulaDataSvc?.hasDataSource() || !column.isAllowFormula()) {
            return null;
        }

        const isFormulaValue = formulaSvc?.isFormula(newValue);
        const existingFormula = formulaDataSvc.getFormula({ column, rowNode });

        if (isFormulaValue) {
            const valueWasDifferent = existingFormula !== newValue;
            if (!valueWasDifferent) {
                return false;
            }

            formulaDataSvc.setFormula({ column, rowNode, formula: newValue });

            // Store the computed value into rowData for consumers that do not understand formulas.
            const computedValue = formulaSvc?.resolveValue(column, rowNode as RowNode);
            const colDef = column.getColDef();
            if (_exists(colDef.valueSetter) || !_missing(colDef.field)) {
                const computedParams: ValueSetterParams = { ...setterParams, newValue: computedValue };
                this.computeValueChange({
                    column,
                    newValue: computedValue,
                    params: computedParams,
                    rowData: rowNode.data,
                    valueSetter: colDef.valueSetter,
                    field: colDef.field,
                });
            }

            return this.finishValueChange(rowNode, column, setterParams, eventSource);
        }

        if (existingFormula !== undefined) {
            formulaDataSvc.setFormula({ column, rowNode, formula: undefined });
        }

        return null;
    }

    private computeValueChange(params: {
        valueSetter: ValueSetterParams['colDef']['valueSetter'];
        params: ValueSetterParams;
        rowData: any;
        field: string | undefined;
        column: AgColumn;
        newValue: any;
    }): boolean | undefined {
        const { valueSetter, params: setterParams, rowData, field, column, newValue } = params;

        if (_exists(valueSetter)) {
            if (typeof valueSetter === 'function') {
                return valueSetter(setterParams);
            }
            return this.expressionSvc?.evaluate(valueSetter, setterParams);
        }

        return this.setValueUsingField(rowData, field, newValue, column.isFieldContainsDots());
    }

    private dispatchCellValueChangedEvent(
        rowNode: IRowNode,
        params: ValueSetterParams,
        value: any,
        source?: string
    ): void {
        this.eventSvc.dispatchEvent({
            type: 'cellValueChanged',
            event: null,
            rowIndex: rowNode.rowIndex!,
            rowPinned: rowNode.rowPinned,
            column: params.column,
            colDef: params.colDef,
            data: rowNode.data,
            node: rowNode,
            oldValue: params.oldValue,
            newValue: value,
            value,
            source,
        });
    }

    private callColumnCellValueChangedHandler(event: CellValueChangedEvent) {
        const onCellValueChanged = event.colDef.onCellValueChanged;
        if (typeof onCellValueChanged === 'function') {
            this.beans.frameworkOverrides.wrapOutgoing(() => {
                onCellValueChanged({
                    node: event.node,
                    data: event.data,
                    oldValue: event.oldValue,
                    newValue: event.newValue,
                    colDef: event.colDef,
                    column: event.column,
                    api: event.api,
                    context: event.context,
                });
            });
        }
    }

    private setValueUsingField(
        data: any,
        field: string | undefined,
        newValue: any,
        isFieldContainsDots: boolean
    ): boolean {
        if (!field) {
            return false;
        }

        // if no '.', then it's not a deep value
        let valuesAreSame: boolean = false;
        if (!isFieldContainsDots) {
            valuesAreSame = data[field] === newValue;
            if (!valuesAreSame) {
                data[field] = newValue;
            }
        } else {
            // otherwise it is a deep value, so need to dig for it
            const fieldPieces = field.split('.');
            let currentObject = data;
            while (fieldPieces.length > 0 && currentObject) {
                const fieldPiece: any = fieldPieces.shift();
                if (fieldPieces.length === 0) {
                    valuesAreSame = currentObject[fieldPiece] === newValue;
                    if (!valuesAreSame) {
                        currentObject[fieldPiece] = newValue;
                    }
                } else {
                    currentObject = currentObject[fieldPiece];
                }
            }
        }
        return !valuesAreSame;
    }

    private executeValueGetterWithValueCache(
        // eslint-disable-next-line @typescript-eslint/ban-types
        valueGetter: string | Function,
        data: any,
        column: AgColumn,
        rowNode: IRowNode
    ): any {
        const colId = column.getColId();

        // if inside the same turn, just return back the value we got last time
        const valueFromCache = this.valueCache!.getValue(rowNode as RowNode, colId);

        if (valueFromCache !== undefined) {
            return valueFromCache;
        }

        const result = this.executeValueGetterWithoutValueCache(valueGetter, data, column, rowNode);

        // if a turn is active, store the value in case the grid asks for it again
        this.valueCache!.setValue(rowNode as RowNode, colId, result);

        return result;
    }

    private executeValueGetterWithoutValueCache(
        // eslint-disable-next-line @typescript-eslint/ban-types
        valueGetter: string | Function,
        data: any,
        column: AgColumn,
        rowNode: IRowNode
    ): any {
        const params: ValueGetterParams = _addGridCommonParams(this.gos, {
            data: data,
            node: rowNode,
            column: column,
            colDef: column.getColDef(),
            getValue: this.getValueCallback.bind(this, rowNode),
        });

        let result;
        if (typeof valueGetter === 'function') {
            result = valueGetter(params);
        } else {
            result = this.expressionSvc?.evaluate(valueGetter, params);
        }

        return result;
    }

    public getValueCallback(node: IRowNode, field: string | AgColumn): any {
        const otherColumn = this.colModel.getColDefCol(field);

        if (otherColumn) {
            return this.getValue(otherColumn, node);
        }

        return null;
    }

    // used by row grouping and pivot, to get key for a row. col can be a pivot col or a row grouping col
    public getKeyForNode(col: AgColumn, rowNode: IRowNode): any {
        const value = this.getValue(col, rowNode);
        const keyCreator = col.getColDef().keyCreator;

        let result = value;
        if (keyCreator) {
            const keyParams: KeyCreatorParams = _addGridCommonParams(this.gos, {
                value: value,
                colDef: col.getColDef(),
                column: col,
                node: rowNode,
                data: rowNode.data,
            });
            result = keyCreator(keyParams);
        }

        // if already a string, or missing, just return it
        if (typeof result === 'string' || result == null) {
            return result;
        }

        result = String(result);

        if (result === '[object Object]') {
            _warn(121);
        }

        return result;
    }
}
